이 글의 모든 예시는 grpc-go 를 기준으로 설명합니다.
1. 화이트 박스 테스트와 블랙 박스 테스트
화이트 박스 테스트(white-box test) 는 테스트 대상의 내부 구현에 직접 접근할 수 있는 테스트를 의미한다.
구조체의 unexported 필드, 내부 함수, 캐시 구조 등을 직접 검증할 수 있다.
반면 블랙 박스 테스트(black-box test) 는 공개 API만으로 동작을 검증한다. 내부가 어떻게 구현되었는지는 알 수 없고 오직 입력과 출력만 검증한다.
| 구분 | 화이트 박스 | 블랙 박스 |
|---|---|---|
| 접근 범위 | unexported 필드, 내부 함수 포함 전체 | 공개 API만 |
| 리팩토링 영향 | 내부 구현 변경 시 테스트 깨짐 | 동작이 같으면 영향 없음 |
| 비유 | 자판기 내부 기어를 열어서 검사 | 동전을 넣고 음료가 나오는지 확인 |
대부분의 언어는 이 구분을 개발자의 의도에 따라 구현할 수 있지만 Go에서는 패키지 시스템이 컴파일 단계에서 격리 여부를 강제한다. 즉 화이트 박스와 블랙 박스의 차이가 단순히 작성 스타일이 아닌 패키지 선언으로 결정된다.
2. Go만의 테스트 패키지 규칙
캡슐화를 우회해 내부를 검증하는 방식은 언어마다 다르다.
| 언어 | 캡슐화 우회 방식 |
|---|---|
| Java | package-private 가시성 + Reflection |
| Python | 컨벤션 (_underscore), 강제력 없음 |
| Go | 같은 디렉토리 + _test 접미사 패키지 |
Java는 리플렉션으로 비공개 필드에 접근하고, Python은 그저 관습적 컨벤션만 있을 뿐 접근을 강제로 막을 방법이 없다. Go는 별도의 런타임 우회 장치 대신 컴파일러가 패키지 경계로 격리 여부를 결정한다.
_test 접미사를 붙여 외부 패키지로 취급
Go에서 테스트 파일의 패키지 선언은 두 가지 중 하나를 택한다.
go// 1, 2 두 파일 모두 동일한 디렉토리(internal/balancergroup/)에 위치한다. // /balancergroup, /balancergroup_test 처럼 별도의 테스트 디렉토리를 만들지 않는다. // 1. 화이트 박스 — 같은 패키지 package balancergroup // 2. 블랙 박스 — _test 접미사가 붙은 별도 패키지 package balancergroup_test
Go는 원래 같은 디렉토리의 모든 .go 파일이 같은 패키지명을 사용하지만 _test.go 파일에는 예외가 있다. 이 파일만 유일하게 원래 패키지명에 _test를 붙인 다른 이름을 선언할 수 있고, 이렇게 하면 컴파일러가 해당 파일을 외부 패키지로 취급한다.
접미사에 _test가 붙은 패키지를 외부 패키지로 취급하는 이유가 무엇일까? Go 컴파일러가 블랙 박스 테스트를 언어 수준에서 지원하기 때문이다.
같은 internal/balancergroup/ 디렉토리에 있어도 서로 격리된 패키지로 취급되면 테스트 코드는 본래 코드의 unexported 필드나 심볼에 접근할 수 없다.
| 개념 | 패키지 선언 | 디렉토리 | 비공개 심볼 접근 |
|---|---|---|---|
| 화이트 박스 | package balancergroup | internal/balancergroup/ | 가능 |
| 블랙 박스 | package balancergroup_test | internal/balancergroup/ | 컴파일러가 차단 |
정확히 말하면 Go에서는
public/private보다exported/unexported라는 표현을 쓴다. 이 글에서는 편의상 공개/비공개라고 부르되, 기준은 Go의 대문자/소문자 규칙이다.
package balancergroup_test로 선언된 테스트 파일에서는 New, Options, Add 같은 대문자 식별자만 사용할 수 있다. handleResolverError나 subBalancerWrapper에 접근하려 하면 컴파일 에러가 발생한다. 실제 오픈소스 프로젝트에서 이 규칙이 어떻게 적용되는지 살펴보자.
go// 공개 — 외부 패키지에서 접근 가능 func New(opts Options) *BalancerGroup { ... } type Options struct { ... } func (bg *BalancerGroup) Add(id string, builder balancer.Builder) { ... } func (bg *BalancerGroup) Close() { ... }
go// 비공개 — 패키지 내부에서만 접근 가능 func (bg *BalancerGroup) handleResolverError(id string, err error) { ... } type subBalancerWrapper struct { ... } var idleTimeout = 10 * time.Minute
3. grpc-go 이슈: move to test only package
internal/balancergroup/의 16개 테스트는 모두 package balancergroup(화이트 박스)으로 선언되어 있었지만, 실제로 비공개 심볼을 하나도 사용하지 않고 있었다.
공개 API만으로 충분히 검증 가능한 테스트들이 화이트 박스 패키지에 묶여 있던 셈이다.
GitHub Issue #8996에 이 16개의 테스트를 package balancergroup_test(블랙 박스)로 마이그레이션하는 내용이 등록되었다.
핵심은 테스트 로직을 다시 쓰는 것이 아니라 패키지 선언을 balancergroup에서 balancergroup_test로 바꾸고 그에 맞춰 타입과 함수를 balancergroup.New, balancergroup.Options처럼 패키지 한정자로 명시하는 것이다. 즉 테스트의 관심사는 유지한 채, 접근 가능한 범위만 좁힌 변경에 가깝다.
화이트 박스에서 가능한 위험한 코드
화이트 박스에서는 이런 코드가 컴파일된다.
go// package balancergroup — 내부 구현에 직접 접근 func TestCacheExpiry(t *testing.T) { bg := New(Options{SubBalancerCloseTimeout: time.Second}) bg.Add("b1", rrBuilder) bg.Remove("b1") bg.cacheMu.Lock() // <- 내부 구현에 접근 if _, ok := bg.balancerCache["b1"]; !ok { // <- 내부 구현에 접근 t.Fatal("b1이 캐시에 없음") } bg.cacheMu.Unlock() }
bg.cacheMu와 bg.balancerCache는 비공개 필드다. 테스트가 내부 캐시 구현에 직접 의존하고 있다
왜 내부 구현에 직접 의존하면 안 될까? 내부 구현은 언제든 바뀔 수 있기 때문이다. 비공개 필드는 외부와의 계약이 아니라 개발자가 비교적 쉽게 수정할 수 있는 영역이다.
동작이 동일하더라도 내부 구조가 바뀌는 순간 테스트는 깨진다.
블랙 박스 전환 후의 코드
블랙 박스로 전환하면 패키지 한정자가 추가되고, 공개 API만 사용하게 된다.
![]()
go// package balancergroup_test — 공개 API만 사용 func initBalancerGroupForCachingTest(t *testing.T, idleCacheTimeout time.Duration) ( *balancergroup.BalancerGroup, ) { bg := balancergroup.New(balancergroup.Options{ ... }) }
New(Options{...})가 balancergroup.New(balancergroup.Options{...})로 바뀌었다.
외부 패키지이므로 패키지 이름을 명시해야 한다.
| 변경 전 (화이트 박스) | 변경 후 (블랙 박스) |
|---|---|
| New(Options{...}) | balancergroup.New(balancergroup.Options{...}) |
| *BalancerGroup | *balancergroup.BalancerGroup |
| ParseConfig(...) | balancergroup.ParseConfig(...) |
화이트 박스가 정말 필요한 경우
그럼 모든 테스트를 블랙 박스로만 작성해야 하는걸까?
모든 테스트를 블랙 박스로 전환할 수 있는 것은 아니다. grpc-go 프로젝트의 health/server_internal_test.go를 보자.
go// package health — 화이트 박스 func (s) TestShutdown(t *testing.T) { s := NewServer() s.SetServingStatus(testService, healthpb.HealthCheckResponse_SERVING) // 비공개 필드 s.statusMap에 직접 접근 status := s.statusMap[testService] if status != healthpb.HealthCheckResponse_SERVING { t.Fatalf("status for %s is %v, want %v", testService, status, healthpb.HealthCheckResponse_SERVING) } // 비공개 필드 s.mu에 직접 접근 s.mu.Lock() status = s.statusMap[testService] s.mu.Unlock() }
이 테스트는 s.statusMap과 s.mu라는 비공개 필드에 직접 접근하여 공개 API만으로는 검증할 수 없는 내부 동시성 안전성을 테스트한다. 이런 경우에는 화이트 박스 테스트가 적절하다.
이슈의 당위성
이슈에 등록된 balancergroup의 16개 테스트는 모두 정상적으로 통과했고 비공개 심볼을 사용하는 코드도 없었다. 왜 굳이 블랙 박스로 전환하자는 이슈가 등록됐을까?
추측컨대 이 변경은 수정적 변경(corrective)이 아니라 예방적 변경(preventive) 이다.
잠금장치가 없는 문을 떠올려 보자. 아무도 열지 않았다고 해서 잠겨 있는 게 아니다. 누군가 문을 열기 전에 잠금장치를 다는 것이 예방이다. 이 잠금장치를 다는 것이 컴파일로 강제화 하는 셈이다.
기존의 package balancergroup 선언은 비공개 필드에 접근할 수 있는 문을 열어둔 셈이다. 지금까지 작성된 테스트 코드는 그 문(비공개 필드)을 사용하지 않지만, 다른 개발자는 테스트를 추가할 때 bg.cacheMu.Lock()처럼 내부 구현에 의존하는 코드를 작성할 수 있다. 코드는 컴파일되고 테스트 코드도 정상적으로 실행될 것이다.
6개월 후 누군가 캐시 구현을 map에서 sync.Map으로 리팩토링하면 어떻게 될까?
bg.cacheMu삭제됨bg.balancerCache["b1"]타입이 바뀜- 동작은 동일한데 테스트가 컴파일 에러로 깨짐
블랙 박스 테스트(package balancergroup_test)로 전환하면 컴파일러가 비공개 필드 접근을 원천 차단한다. 미래의 실수를 사람이 아니라 컴파일러에게 맡기는 것이다.
4. 관찰 가능한 동작을 테스트하라
블랙 박스 테스트는 공개 API만 사용하도록 강제한다. 블랙 박스 테스트의 장점은 무엇일까? 내부 구현이 변경되더라도 공개 API의 동작이 동일하면 테스트가 깨지지 않는다. 즉 리팩토링시 갑작스레 발목 잡는 일이 덜 발생한다.
그렇다면 내부 상태의 변화를 어떻게 검증할까? 이에 관해서 Martin Fowler는 다음과 같이 말했다.
Don't reflect your internal code structure within your unit tests. Test for observable behaviour instead.
즉 unexported 메서드 같은 상세한 구현을 테스트하는 것이 아니라 관찰 가능한 동작(observable behavior) 을 테스트해야 한다.
balancergroup의 블랙 박스 테스트는 채널을 활용하여 외부에서 관찰 가능한 이벤트를 기다린다.
이 코드가 실제로 검증하는 결과는 내부 캐시 구조 자체가 아닌, 새 SubConn이 기대한 수만큼 생성되고 최종적으로 만들어진 Picker가 round-robin 순서를 만족하는지다.
go// package balancergroup_test m1 := make(map[string]*testutils.TestSubConn) for i := 0; i < 4; i++ { addrs := <-cc.NewSubConnAddrsCh // 새 연결 주소가 생성될 때까지 대기 sc := <-cc.NewSubConnCh // 새 SubConn이 생성될 때까지 대기 m1[addrs[0].Addr] = sc sc.UpdateState(balancer.SubConnState{ConnectivityState: connectivity.Connecting}) sc.UpdateState(balancer.SubConnState{ConnectivityState: connectivity.Ready}) } p1 := <-cc.NewPickerCh // 새 Picker가 생성될 때까지 대기 want := []balancer.SubConn{ m1[testBackendAddrs[0].Addr], m1[testBackendAddrs[0].Addr], m1[testBackendAddrs[1].Addr], m1[testBackendAddrs[1].Addr], m1[testBackendAddrs[2].Addr], m1[testBackendAddrs[3].Addr], } if err := testutils.IsRoundRobin(want, testutils.SubConnFromPicker(p1)); err != nil { t.Fatalf("want %v, got %v", want, err) }
<-cc.NewSubConnAddrsCh, <-cc.NewSubConnCh, <-cc.NewPickerCh는 모두 채널에서 값을 꺼내는 연산이다. 내부 캐시를 직접 들여보는 대신 외부에서 관찰할 수 있는 이벤트가 발생하는지를 기다린다.
자판기에 비유하면 내부 기어가 제대로 돌아가는지 뚜껑을 열어 확인하는 것이 아닌, 동전을 넣었을 때 음료가 나오는지를 확인하는 것이다. 이것이 블랙 박스 테스트가 강제하는 설계 패턴으로 내부 구현이 바뀌어도 관찰 가능한 동작이 같다면 테스트는 깨지지 않는다.
마무리
테스트 격리성은 테스트가 내부 구현에 의존하지 않도록 경계를 긋는 것이다. Go는 패키지 선언만으로 그 경계를 컴파일러에게 위임할 수 있다. 이번 컨트리뷰션을 통해 실제 현업에서도 적용할 수 있는 체크리스트 세 가지를 남긴다.
- 테스트 파일의 패키지 선언을 확인하고,
_test접미사 없이 같은 패키지로 선언되어 있다면 비공개 심볼을 실제로 사용하고 있는지 점검한다. - 비공개 심볼을 사용하지 않는 화이트 박스 테스트는 블랙 박스로 전환을 고려한다. 패키지 한정자를 추가하는 것만으로 충분하다.
- 새로운 테스트를 작성할 때 기본값을
package xxx_test(블랙 박스)로 설정한다. 화이트 박스가 필요한 경우에만 의식적으로 선택한다.